Skip to content

Conversation

mfts
Copy link
Owner

@mfts mfts commented Sep 3, 2025

Add support for text content in agreements to allow users to display compliance information directly as a paragraph.

This enhancement extends the existing agreement system, which previously only supported external links. Now, users can choose to provide a direct text paragraph for their agreements, which will be displayed prominently to end-users. This involves updates to the database schema, agreement creation/editing UI, agreement display UI, and the API.


Slack Thread

Open in Cursor Open in Web

Summary by CodeRabbit

  • New Features

    • Create agreements as either a link or plain text with a toggle; upload support for link-type agreements.
    • Access forms and document/dataroom views can show agreements as inline text or as hyperlinks.
  • Enhancements

    • Stronger validation and sanitization for submitted agreement content with clear user-facing error messages.
    • Form state resets after submit/close for a smoother workflow.
  • Chores

    • Added system-wide support for agreement content types and related API/schema updates.

Copy link

cursor bot commented Sep 3, 2025

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

Copy link
Contributor

coderabbitai bot commented Sep 3, 2025

Walkthrough

Adds content-type support for agreements (LINK or TEXT) across UI, API, and database; updates agreement creation UI and payload shape; forwards contentType through view components to render text vs link; adds Zod validation and sanitization in the agreements API; introduces document-focused Prisma models in a new schema file while removing them from the original schema; adds a DB migration to add Agreement.contentType.

Changes

Cohort / File(s) Summary
Agreement creation UI
components/links/link-sheet/agreement-panel/index.tsx
Adds contentType (LINK/TEXT) RadioGroup; conditional inputs (URL vs textarea); URL validation and urlError state; validates non-empty text for TEXT; builds and posts structured payload { name, contentType, content, requireName }; expands defaultData to include contentType and textContent; resets local state on close/submission.
Access form & agreement rendering
components/view/access-form/agreement-section.tsx, components/view/access-form/index.tsx
Adds optional prop agreementContentType?: string; AgreementSection chooses between rendering plain text (preserve whitespace) when TEXT or an anchor when LINK; minor Checkbox className change; AccessForm accepts and forwards agreementContentType.
View components forwarding prop
components/view/dataroom/dataroom-document-view.tsx, components/view/dataroom/dataroom-view.tsx, components/view/document-view.tsx
Passes link.agreement?.contentType as agreementContentType into AccessForm; no other control-flow changes.
Agreements API
pages/api/teams/[teamId]/agreements/index.ts
Adds Zod createAgreementSchema validating { name, content, contentType, requireName }; uses safeParse with 400 on validation errors; sanitizes content via validateContent; persists content and contentType (default "LINK") in Prisma create; GET unchanged.
DB migration (Agreement)
prisma/migrations/20250903000000_add_agreement_contenttype/migration.sql
Adds non-null contentType column to Agreement with default 'LINK'.
Prisma schema: documents added
prisma/schema/document.prisma
New file introducing Document, DocumentVersion, DocumentPage, Folder, DocumentUpload models and DocumentStorageType enum with fields, relations, indexes, and defaults for document management.
Prisma schema: documents removed & agreement updated
prisma/schema/schema.prisma
Removes Document, DocumentVersion, DocumentPage, DocumentUpload, Folder, and DocumentStorageType; removes DATAROOM_TAG from TagType; adds Agreement.contentType String @default("LINK").
Cron domains filter tweak
app/api/cron/domains/route.ts
Changes domain exclusion from not: { contains: "papermark.io" } to not: { in: ["papermark.io", "papermark.com"] } (match exact hostnames).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as AgreementSheet (UI)
  participant API as /api/teams/{teamId}/agreements
  participant DB as Database

  rect rgb(239,246,255)
  note over UI: User selects content type
  User->>UI: Choose LINK or TEXT
  UI-->>User: Show URL input or Textarea
  end

  alt LINK
    UI->>UI: Validate URL (agreementUrlSchema)
    UI-->>User: Show url error if invalid
  else TEXT
    UI->>UI: Ensure non-empty textContent
    UI-->>User: Show error if empty
  end

  UI->>API: POST { name, contentType, content, requireName }
  API->>API: Zod validate & sanitize content
  API->>DB: Create Agreement(name, contentType, content, requireName)
  DB-->>API: Created
  API-->>UI: Success
  UI->>UI: Reset form and mutate list
Loading
sequenceDiagram
  actor Viewer
  participant View as *View Components*
  participant AF as AccessForm
  participant AS as AgreementSection

  Viewer->>View: Open protected link
  View->>AF: Render with agreementContentType = link.agreement?.contentType
  AF->>AS: agreementContentType, agreementName, agreementContent
  alt agreementContentType == "TEXT"
    AS-->>Viewer: Render plain text (preserve whitespace)
  else
    AS-->>Viewer: Render anchor (agreementName → URL)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cursor/update-link-agreement-to-show-compliance-text-7d7a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

vercel bot commented Sep 3, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
papermark Ready Ready Preview Comment Sep 7, 2025 9:33am

@mfts mfts marked this pull request as ready for review September 7, 2025 08:31
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
components/view/access-form/index.tsx (1)

167-175: Fix: TEXT agreements won’t render due to agreementName gate.

AgreementSection is not shown when contentType is TEXT and agreementName is absent.

-            {requireAgreement && agreementContent && agreementName ? (
+            {requireAgreement &&
+              agreementContent &&
+              (agreementContentType === "TEXT" || !!agreementName) ? (
               <AgreementSection
                 {...{ data, setData, brand }}
                 agreementContent={agreementContent}
                 agreementName={agreementName}
                 agreementContentType={agreementContentType}
                 useCustomAccessForm={useCustomAccessForm}
               />
             ) : null}
components/links/link-sheet/agreement-panel/index.tsx (2)

135-153: Guard against missing teamId and avoid non-null assertions.

teamId! can produce runtime errors and bad URLs.

   try {
     setIsLoading(true);

     const contentType = currentFile.type;
     const supportedFileType = getSupportedContentType(contentType);

+    if (!teamId) {
+      toast.error("No active team found.");
+      return;
+    }

And below:

-      const { type, data, numPages, fileSize } = await putFile({
+      const { type, data, numPages, fileSize } = await putFile({
         file: currentFile,
-        teamId: teamId!,
+        teamId,
       });

And:

-      const response = await createAgreementDocument({
+      const response = await createAgreementDocument({
         documentData,
-        teamId: teamId!,
+        teamId,
         numPages,
       });

155-162: Harden link extraction and avoid hard-coded domain.

Handle missing links defensively and build the URL from config/origin.

-      if (response) {
-        const document = await response.json();
-        const linkId = document.links[0].id;
-        setData((prevData) => ({
-          ...prevData,
-          link: "https://www.papermark.com/view/" + linkId,
-        }));
-      }
+      if (response) {
+        const doc = await response.json();
+        const linkId = doc?.links?.[0]?.id;
+        if (!linkId) {
+          toast.error("No link generated for the uploaded agreement.");
+        } else {
+          const baseUrl =
+            process.env.NEXT_PUBLIC_APP_URL ||
+            (typeof window !== "undefined" ? window.location.origin : "");
+          setData((prev) => ({
+            ...prev,
+            link: `${baseUrl}/view/${linkId}`,
+          }));
+        }
+      }
🧹 Nitpick comments (10)
components/view/access-form/index.tsx (1)

56-59: Tighten the prop type to a string union.

Avoids accidental typos and aligns with backend constraint.

-  agreementContentType?: string;
+  agreementContentType?: "LINK" | "TEXT";
components/links/link-sheet/agreement-panel/index.tsx (6)

63-69: Type the local state and narrow contentType.

Avoid implicit any and lock contentType to the union.

-  const [data, setData] = useState({ 
+  const [data, setData] = useState<{
+    name: string;
+    link: string;
+    textContent: string;
+    contentType: "LINK" | "TEXT";
+    requireName: boolean;
+  }>({
     name: "", 
     link: "", 
     textContent: "",
     contentType: "LINK",
     requireName: true 
   });

91-99: Reset validation state on close.

Clear URL validation state to avoid stale UI when reopening.

   const handleClose = (open: boolean) => {
     setIsOpen(open);
     setData({ 
       name: "", 
       link: "", 
       textContent: "",
       contentType: "LINK",
       requireName: true 
     });
+    setUrlError("");
+    setIsUrlValid(true);
     setCurrentFile(null);
     setIsLoading(false);
     if (onClose) {
       onClose();
     }
   };

226-231: Consider aligning submit DTO with a shared type.

If a shared AgreementCreateRequest exists, import it to prevent drift.

Would you like a quick shared type stub I can add under types/api.ts?


251-260: Deduplicate close/reset logic; call handleClose(false).

Keeps behavior consistent and ensures onClose runs.

-      setIsLoading(false);
-      setIsOpen(false);
-      setData({ 
-        name: "", 
-        link: "", 
-        textContent: "",
-        contentType: "LINK",
-        requireName: true 
-      });
+      setIsLoading(false);
+      handleClose(false);

378-379: Clarify accepted upload types to match validation.

UI currently suggests many types, but handler allows only PDF/Word.

-                          <Label>Or upload an agreement</Label>
+                          <Label>Or upload an agreement (PDF or Word)</Label>

430-433: Optional: disable submit on empty LINK.

Relying solely on required works, but pre-emptive disable improves UX.

   disabled={
-      (data.contentType === "LINK" && !isUrlValid && data.link.trim() !== "") ||
+      (data.contentType === "LINK" && (!data.link.trim() || !isUrlValid)) ||
       (data.contentType === "TEXT" && !data.textContent.trim())
     }
prisma/schema/document.prisma (3)

69-70: Fix truncated comment.

-  pageNumber    Int // e.g., 1, 2, 3 for 
+  pageNumber    Int // e.g., 1, 2, 3 for page numbering

72-72: Correct typo in comment (originalHeight).

-  metadata      Json? // This will store the page metadata: {originalWidth: 100, origianlHeight: 100, scaledWidth: 50, scaledHeight: 50, scaleFactor: 2}
+  metadata      Json? // This will store the page metadata: {originalWidth: 100, originalHeight: 100, scaledWidth: 50, scaledHeight: 50, scaleFactor: 2}

31-34: Name pluralization (optional).

uploadedDocument is an array; consider uploadedDocuments for clarity (breaking change).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0740a4e and 7b56525.

📒 Files selected for processing (10)
  • components/links/link-sheet/agreement-panel/index.tsx (7 hunks)
  • components/view/access-form/agreement-section.tsx (1 hunks)
  • components/view/access-form/index.tsx (3 hunks)
  • components/view/dataroom/dataroom-document-view.tsx (1 hunks)
  • components/view/dataroom/dataroom-view.tsx (1 hunks)
  • components/view/document-view.tsx (1 hunks)
  • pages/api/teams/[teamId]/agreements/index.ts (1 hunks)
  • prisma/migrations/20250903000000_add_agreement_contenttype/migration.sql (1 hunks)
  • prisma/schema/document.prisma (1 hunks)
  • prisma/schema/schema.prisma (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
components/links/link-sheet/agreement-panel/index.tsx (2)
context/team-context.tsx (1)
  • useTeam (85-85)
components/document-upload.tsx (1)
  • DocumentUpload (25-202)
components/view/access-form/agreement-section.tsx (2)
components/view/access-form/index.tsx (1)
  • DEFAULT_ACCESS_FORM_TYPE (20-26)
lib/utils/determine-text-color.ts (1)
  • determineTextColor (24-28)
🔇 Additional comments (6)
components/view/dataroom/dataroom-document-view.tsx (1)

298-316: LGTM: Propagates agreementContentType correctly to AccessForm.

components/view/document-view.tsx (1)

254-272: LGTM: agreementContentType is forwarded as expected.

components/view/dataroom/dataroom-view.tsx (1)

239-257: LGTM: agreementContentType plumbed through to AccessForm.

components/links/link-sheet/agreement-panel/index.tsx (1)

391-415: Safe HTML-escaped rendering for TEXT agreement content confirmed
AgreementSection renders {agreementContent} inside a <span>, so React’s JSX interpolation automatically escapes any HTML.

prisma/schema/document.prisma (2)

82-85: Enum looks good.

Default to VERCEL_BLOB is sensible; no issues.


1-1: Remove manual merge step Multi-file schema composition is auto-managed by Prisma when the prismaSchemaFolder preview feature is enabled in your generator; all .prisma files in prisma/schema are merged during prisma generate, validate, migrate, etc. (prismagraphql.com, prisma.io)

@@ -50,15 +52,21 @@ export default function AgreementSheet({
isOnlyView = false,
onClose,
}: {
defaultData?: { name: string; link: string; requireName: boolean } | null;
defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Strongly type contentType ('LINK' | 'TEXT') to prevent typos.

Use a literal union instead of string for safety across the app.

-  defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
+  defaultData?: { name: string; link: string; requireName: boolean; contentType?: "LINK" | "TEXT"; textContent?: string } | null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
defaultData?: {
name: string;
link: string;
requireName: boolean;
contentType?: "LINK" | "TEXT";
textContent?: string;
} | null;
🤖 Prompt for AI Agents
In components/links/link-sheet/agreement-panel/index.tsx around line 55, the
defaultData prop types use contentType?: string which is error-prone; change
that field to a literal union contentType?: 'LINK' | 'TEXT' to enforce allowed
values, update any local interfaces or type aliases accordingly, and then fix
any usages/assignments across the file (or imported callers) to use the 'LINK'
or 'TEXT' literals so the compiler catches typos.

Comment on lines 84 to 85
requireName: defaultData?.requireName || true,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Boolean defaulting bug: false gets coerced to true.

|| true overrides an explicit false. Use nullish coalescing.

-        requireName: defaultData?.requireName || true,
+        requireName: defaultData?.requireName ?? true,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
requireName: defaultData?.requireName || true,
});
requireName: defaultData?.requireName ?? true,
});
🤖 Prompt for AI Agents
In components/links/link-sheet/agreement-panel/index.tsx around lines 84-85, the
expression "requireName: defaultData?.requireName || true" incorrectly coerces
an explicit false to true; replace the logical-or with nullish coalescing so it
respects false—i.e. use "defaultData?.requireName ?? true" (ensure optional
chaining remains and the result is typed/handled as a boolean).

Comment on lines 233 to 239
const response = await fetch(`/api/teams/${teamId}/agreements`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
}),
body: JSON.stringify(submitData),
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Guard POST against missing teamId.

Same concern as upload flow.

Add before fetch:

+    if (!teamId) {
+      toast.error("No active team found.");
+      return;
+    }

Run to confirm no other ! assertions remain:


🏁 Script executed:

#!/bin/bash
rg -nP --type tsx -C1 '\bteamId!?\b'

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for non-null assertions on teamId in .ts and .tsx files
rg -nP '\bteamId\s*!' -g '*.ts' -g '*.tsx'

Length of output: 4058


Add guard for missing teamId in agreement-panel

In components/links/link-sheet/agreement-panel/index.tsx you still have non-null assertions on teamId at lines 137, 151 and the fetch at 233. Add before each usage:

+  if (!teamId) {
+    toast.error("No active team found.");
+    return;
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(`/api/teams/${teamId}/agreements`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
}),
body: JSON.stringify(submitData),
});
if (!teamId) {
toast.error("No active team found.");
return;
}
const response = await fetch(`/api/teams/${teamId}/agreements`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(submitData),
});
🤖 Prompt for AI Agents
In components/links/link-sheet/agreement-panel/index.tsx around lines 137, 151
and 233–239, you are using non-null assertions on teamId and calling fetch with
teamId without guarding for it; add an explicit guard before each usage (e.g.,
if (!teamId) { handle the missing teamId path — return early, disable the
action, or surface an error) so you never call fetch or access teamId when it's
undefined, and update UI/error handling accordingly to avoid runtime exceptions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
pages/api/teams/[teamId]/agreements/index.ts (2)

14-23: Harden schema: trim, enforce http/https for LINK, and cap TEXT length.

As-is, " " passes min(1) then becomes empty after .trim(). Also, LINK allows javascript: (XSS vector) and very long TEXT is unbounded.

Apply this diff to the schema:

-const createAgreementSchema = z.object({
-  name: z
-    .string()
-    .min(1, "Name is required")
-    .max(150, "Name must be less than 150 characters"),
-  content: z.string().min(1, "Content is required"),
-  contentType: z.enum(["LINK", "TEXT"]).default("LINK"),
-  requireName: z.boolean().default(false),
-});
+const createAgreementSchema = z
+  .object({
+    name: z
+      .string()
+      .trim()
+      .min(1, "Name is required")
+      .max(150, "Name must be less than 150 characters"),
+    content: z.string().trim().min(1, "Content is required"),
+    contentType: z.enum(["LINK", "TEXT"]).default("LINK"),
+    requireName: z.boolean().default(false),
+  })
+  .superRefine((val, ctx) => {
+    if (val.contentType === "LINK") {
+      try {
+        const url = new URL(val.content);
+        if (url.protocol !== "http:" && url.protocol !== "https:") {
+          ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            path: ["content"],
+            message: "Only http/https URLs are allowed for LINK contentType.",
+          });
+        }
+      } catch {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          path: ["content"],
+          message: "Invalid URL for LINK contentType.",
+        });
+      }
+    } else {
+      // TEXT
+      if (val.content.length > 5000) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.too_big,
+          maximum: 5000,
+          type: "string",
+          inclusive: true,
+          path: ["content"],
+          message: "Text content exceeds 5000 characters.",
+        });
+      }
+    }
+  });

113-115: Sanitize TEXT; normalize LINK.

Sanitizing a URL string doesn’t prevent javascript: or data: schemes. Use sanitization only for TEXT and persist a normalized URL for LINK.

Apply this diff:

-      // Sanitize content using existing sanitization logic
-      const sanitizedContent = validateContent(content);
+      // Sanitize/normalize by contentType
+      const sanitizedContent =
+        contentType === "TEXT"
+          ? validateContent(content)
+          : new URL(content).toString();
🧹 Nitpick comments (2)
pages/api/teams/[teamId]/agreements/index.ts (2)

111-112: Minor: Schema default makes fallback unnecessary downstream.

Since defaults are applied by Zod, all fields are present and normalized here.


119-122: Drop redundant default and rely on validated/normalized values.

Zod already defaulted contentType to "LINK"; also avoid storing post-trim empty names.

Apply this diff:

-          name: name.trim(),
-          content: sanitizedContent,
-          contentType: contentType || "LINK",
+          name,
+          content: sanitizedContent,
+          contentType,

If you prefer always-trimmed name/content at the DB edge, keep .trim() but pair it with the schema .trim() to ensure min(1) still holds after trimming.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b56525 and a6259c6.

📒 Files selected for processing (2)
  • app/api/cron/domains/route.ts (1 hunks)
  • pages/api/teams/[teamId]/agreements/index.ts (2 hunks)
🔇 Additional comments (4)
app/api/cron/domains/route.ts (1)

41-43: ```shell
#!/bin/bash

Locate the domain route file

FILE=$(find . -type f -path '*/app/api/cron/domains/route.ts' | head -n 1)
echo "Found file: $FILE"

Print lines around the slug filter for review

sed -n '1,200p' "$FILE"


</blockquote></details>
<details>
<summary>pages/api/teams/[teamId]/agreements/index.ts (3)</summary><blockquote>

`4-4`: **LGTM on Zod adoption.**

Importing Zod is a solid step toward consistent validation.

---

`10-10`: **LGTM on sanitization import.**

Centralizing sanitization is good; see follow-up to scope it to TEXT only.

---

`102-110`: **Good switch to safeParse with structured 400s.**

Error shape is clear and client-friendly.

</blockquote></details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

@mfts mfts merged commit 8579515 into main Sep 7, 2025
9 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Sep 7, 2025
@mfts mfts deleted the cursor/update-link-agreement-to-show-compliance-text-7d7a branch September 16, 2025 14:39
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants